feat(policies): expiry + scope lifecycle controls (token-optimization R2) - #1893
Merged
simple-agent-manager[bot] merged 13 commits intoAug 24, 2026
Merged
Conversation
Contributor
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Policies were injected into every session forever with no shelf life, so
one-shot workflow policies ('use profile X for the 2026-08-21 wave') kept
loading into every future session long after the work finished.
- DO migration 034: additive ALTER TABLE ADD COLUMN for expires_at and scope.
Defaults (NULL / 'always') reproduce today's behavior exactly, so every
existing policy is unaffected.
- getActivePolicies filters expired at read time. No sweep or alarm (rule 47);
getPolicy and listPolicies deliberately keep returning expired rows so a
human can still see why a policy stopped applying.
- scope='task' requires expiresAt, enforced by one shared validator used by
both the MCP and REST write boundaries (rule 24) plus a final DO-level guard.
- The per-project cap counts only policies that still apply, so inert expired
rows cannot consume cap headroom.
- get_instructions annotates expiring policies and now tells agents to give
one-shot policies an expiry when capturing them.
Co-Authored-By: Claude <noreply@anthropic.com>
…ite boundaries
- policy-do.test.ts: expiry predicate against real DO SQLite via the real
migration chain. Proven discriminating 2026-08-23 — deleting the expiry
conjunct turns exactly the two expired-policy tests red while the
null-expiry backward-compat control stays green.
- Guard tests use runInDurableObject rather than the RPC stub: a stub-level
rejection is also surfaced by the pool as an unhandled rejection and fails
the run even when the assertion passes.
- Shared validatePolicyLifecycle unit tests: boundary, horizon, scope enum.
- MCP + REST tests prove BOTH write boundaries reject a task-scoped policy
with no expiry, and that an update is validated against the merged
post-write state rather than the patch alone.
- get_instructions tests assert the expiry annotation actually reaches the
rendered directives, with a control proving standing policies stay unannotated.
Also removes stray {} arguments from three pre-existing createPolicy test
calls — that argument position is now scope, so they would have bound an
object into SQLite.
Co-Authored-By: Claude <noreply@anthropic.com>
…task checklist Co-Authored-By: Claude <noreply@anthropic.com>
The suite was failing 12 of 13 tests on origin/main, independent of this
branch (verified by reverting these changes and re-running).
Root cause: the spec mocked GET /api/credentials as {credentials: []}, but
listCredentials() returns a bare CredentialResponse[]. OnboardingChecklist
passes that straight to hasByocComputeCredential, which calls .some() on it,
so the app threw 'e.some is not a function' and the ErrorBoundary replaced
the entire page — every test then failed on its first locator regardless of
what it asserted. The two endpoints genuinely have different shapes, so the
exact-path check has to come before the prefix check.
Also mocks /api/report-issue/config and /api/config/vapid-public-key, which
the app shell fetches on every authenticated page and the spec predates.
Co-Authored-By: Claude <noreply@anthropic.com>
- Thread scope/expiresAt through the SAM-session add_policy tool, the third production writer of createPolicy (wired into both the sam-session and project-agent tool registries). Enumerating only the MCP and REST callers missed it, so every policy created from the orchestrator surfaces was permanently non-expiring — the exact failure this feature exists to prevent. - REST PATCH now maps the DO guard rejection to a 400 instead of letting a plain Error become an opaque 500 that also lands in /admin/errors. - Migration comment no longer uses backticks: the migration-safety scripts test extracts every backtick literal in that file and validates it as SQL, so inline-code prose failed the CI gate. - Cap-count test now drives the real cap boundary via runInDurableObject instead of re-asserting the read filter; it previously passed even with the COUNT query reverted to filter on active alone. - Rejection-path tests added for both write boundaries. - Removed a scratch diagnostic Playwright spec that was committed by mistake. Co-Authored-By: Claude <noreply@anthropic.com>
The audit suite still timed out on every click after the credentials-shape fix: useSetupStatus computes isComplete = hasAgent && hasCloud && hasGitHub, and when that is false OnboardingProvider auto-opens ChoosePathWizard, which AppShell mounts on every authenticated page as a modal overlay that intercepts pointer events. Every page.click then timed out on a dialog the spec never mentions. The mocks now satisfy all three conditions. Result: 60/60 across all four viewports, green for the first time. Screenshot byte sizes differ per viewport (286K/375K/689K/720K), confirming the run captured four distinct renders rather than the same page four times. Also adds the maxExpiryMs env-override and invalid-value coverage that every sibling PolicyLimits field already had. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Ten local reviewers ran against the rebased branch. No CRITICAL. One HIGH,
fixed here. Every remaining MEDIUM is either fixed or tracked.
HIGH — the DO migration-safety gate was green for the wrong reason.
`extractSqlFromTypeScript` matched only backtick and single-quoted strings, so
migration 034's second statement — double-quoted precisely BECAUSE it embeds
`DEFAULT 'always'` — was never handed to the danger checks at all. The file
reported PASS without that statement being inspected. Reproduced directly: a
`sql.exec("DROP TABLE project_policies")` is invisible to the scanner. Rule 31
calls this gate one that "cannot be bypassed", so a quoting style must not be
able to bypass it. The extractor now covers all three string forms, each pattern
anchored to its own delimiter so a quote inside one string cannot pair with a
delimiter from a neighbouring statement.
Proven discriminating: against the pre-fix two-pattern extractor exactly the four
double-quote tests go red and the seven pre-existing ones stay green.
Also from review:
- SAM-session `add_policy` had two of the edge cases MCP and REST each cover for
their own parser. Added past-expiry, beyond-horizon, unknown-scope and
non-numeric-expiry, all asserting the write does NOT happen. Proven
discriminating against both the shared validator and the inline guards.
- MCP `update_policy` lacked the I/O-budget regression the REST route has, on the
path agents actually call. Added; proven discriminating by making the pre-read
unconditional.
- The Playwright worst case was split across two cards: `pl4` carried the 200-char
title but was not expired, so "longest title" and "most badges" never met. `pl4`
is now the real worst case and both viewports assert the badges on that card
(page-wide would now be a strict-mode violation, not a pass). 60/60 pass;
screenshots opened and checked.
- Public `architecture/overview.md` documents `formatPolicyDirectives()` output
and did not mention the annotation; added, with the read-time-filter semantics.
Added the `CLAUDE.md` Recent Changes entry (rule 01, same PR).
- The task file's writer enumeration listed two of three writers. Recorded the
third and the near-miss it caused.
Not fixed, tracked instead: expired rows are deliberately retained and no longer
count toward the cap, so nothing bounds total row growth. A naive total ceiling is
worse than the growth — `removePolicy` is a soft delete, so a project that hit the
ceiling could never write a policy again. Documented at the cap site and tracked in
tasks/backlog/2026-08-23-policy-row-retention-bound.md (rule 42).
Co-Authored-By: Claude <noreply@anthropic.com>
CI's `quality:ast-checks` (not part of `check:fast`, which is why my local run
was green) failed with three errors, all mine:
sql-injection policies.ts:71 — template literal with ${APPLIES_NOW_SQL}
sql-injection policies.ts:216 — same
parameterized-sql policies.ts:216 — 1 placeholder but 2 parameters
The interpolated value is a module constant, so there was no injection risk —
but the rule is right that a statement assembled at the `sql.exec()` call site
cannot be checked, and the placeholder miscount is the direct consequence: the
checker cannot see the `?` hidden inside the interpolated constant, so it read
`LIMIT ?` as the only placeholder against two bound parameters.
Compose both statements once at module scope instead. The string handed to
`sql.exec()` is now a fixed load-time constant with no call-site interpolation,
every executed statement is greppable in one place, and the predicate stays
single-sourced so the cap count and the injection read cannot drift apart.
Behaviour is unchanged, and I re-proved the discriminating property against the
restructured code rather than assuming it survived: replacing the expiry
conjunct with a tautology still turns exactly the same two real-DO tests red
("excludes an expired policy…", "excludes expired policies from the per-project
cap"), with the null-expiry control still green. Restored, 17/17.
`quality:ast-checks` now reports 0 errors (240 pre-existing throw-without-log
warnings unchanged). Also ran the rest of that CI job locally — file-sizes,
stale-artifacts, migration-safety, wrangler-bindings all pass.
Co-Authored-By: Claude <noreply@anthropic.com>
simple-agent-manager
Bot
force-pushed
the
sam/continue-complete-policy-lifecycle-e3tm8a
branch
from
August 24, 2026 08:28
d836ae7 to
3508d70
Compare
Move PolicyEntry, formatPolicyLifecycle, formatPolicyDirectives, and buildPolicyInstructions from instruction-tools.ts (820 lines) to instruction-formatting.ts (133 lines), bringing instruction-tools.ts to 699 lines — well under the 800-line hard limit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sort The previous commit removed the policy formatting helpers from instruction-tools.ts but did not include the new instruction-formatting.ts file they were extracted to. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
R2 of the token-optimization program (research: library
/engineering/research/token-optimization-research.md§3.3 and §8/R2; idea01M0QGZQ15WE9DDQENGV6S9XZ5). Follows R1 (#1891), which removed the duplicatedknowledgeContext/policyContextarrays.getActivePoliciesinjects every active policy into every session, unranked and with no shelf life. This project has 81 active policies against a cap of 100, and several of them describe work that finished weeks ago — "Use Codex 5.5 High Chat VMs for current reliability workflow" names a 2026-08-21 workstream that is over. They will keep loading into every future session's opening turn until a human notices. There was no way for an agent capturing a genuinely temporary constraint to mark it as temporary:add_policyhad no expiry and no scope, so every policy was implicitly permanent.Two additive columns fix that:
expires_at INTEGER(nullable) — the filter.NULLmeans "never expires", which is exactly today's behaviour, so all 81 existing rows are unaffected by construction.scope TEXT NOT NULL DEFAULT 'always'('always' | 'task') — the discriminator, backfilled to'always'.The load-bearing part is that these two are coupled: a
task-scoped policy MUST carry anexpiresAt, enforced at every write boundary. That is the actual mechanism that stops a one-shot constraint from becoming permanent — an agent cannot mark a policy as tied to a specific workflow without also giving it a shelf life.Filtering happens at read time only —
active = 1 AND (expires_at IS NULL OR expires_at > ?). No sweep, no cron, no alarm (rule 47: aWHEREclause answers this for free). The row is deliberately retained and staysactive, soget_policy,list_policies, and the Policies tab can still show a human that a policy existed and when it lapsed. Only the agent-injection read filters.Implementation notes for reviewers
Three write boundaries, not two. The original enumeration listed MCP and REST and missed
durable-objects/sam-session/tools/add-policy.ts— the SAM orchestrator's ownadd_policy. Left unfixed, every policy created from an orchestrator surface would have been permanently non-expiring: the exact failure this feature exists to remove, reintroduced through the one door nobody counted. All three now call one sharedvalidatePolicyLifecycle, and the DO re-checks the invariant against freshly-read state immediately before the write — the choke point a future fourth writer cannot bypass (rules 44/51/61).updatePolicyuses an explicit!== undefinedcheck forexpiresAt, not??.nullis a meaningful value here — it clears an expiry — andupdates.expiresAt ?? existing.expiresAtwould silently read "clear this expiry" as "leave it alone".The scope/expiry invariant is validated against the merged post-write state, not the patch alone. Otherwise
{scope:'task'}on a policy that already has an expiry would be wrongly rejected, and{expiresAt:null}on a task-scoped policy would wrongly succeed and resurrect a permanent one-shot policy.Migration
034is strictly additive — twoALTER TABLE ADD COLUMN, no recreation, noDROP. A Durable Object has no D1-style time-travel recovery, so a drop-and-restore here would be unrecoverable (rules 31/63).Rule 63 enumeration (every query reading
project_policies, and whether it is an authorization predicate) is recorded in the task file. None of them is: project scoping here is structural — the ProjectData DO is the project (idFromName(projectId)), so there is noproject_idcolumn that a widening could drop from aWHERE. Rule 63's failure mode does not apply, but the enumeration is recorded because the rule requires it.A quality gate that was green for the wrong reason
Review turned up something worth calling out separately, because it is not really about this feature.
scripts/quality/check-do-migration-safety.tsextracted SQL by matching backtick and single-quoted strings only. Migration 034's second statement is double-quoted — necessarily, because it embedsDEFAULT 'always'. So that statement was never handed to the danger checks at all. The file reportedPASSwithout it ever being inspected.Reproduced directly:
Rule 31 describes this gate as one that "cannot be bypassed". A
DROP TABLEon a Durable Object is unrecoverable, and the choice of quote character was silently deciding whether it got checked. The extractor now covers all three string forms, each pattern anchored to its own delimiter so a quote inside one string cannot pair with a delimiter from a neighbouring statement. Six regression tests added, proven discriminating.This is a latent hole in a data-loss gate, unrelated to policies — it just happened to be found here because this migration was the first to use double quotes.
Not fixed — tracked instead
Expired rows are deliberately retained and no longer count toward the per-project cap, so nothing bounds total row growth (flagged independently by security-auditor and performance-reviewer, both MEDIUM). A naive total ceiling is worse than the growth it prevents:
removePolicyis a soft delete, so a project that reached the ceiling could never write a policy again — a slow growth problem turned into a permanent write lockout. Sizing a ceiling requires first deciding a retention story. Documented at the cap site and tracked intasks/backlog/2026-08-23-policy-row-retention-bound.md(rule 42 — tracked, not silent).Part 2 — production data cleanup
Executed after this merges and deploys, using
remove_policy(reversible deactivation, never a destructive delete). The deactivated ids + titles + justifications are reported in a follow-up comment on this PR and viaupdate_task_status.Validation
pnpm lint(viapnpm check:fast— 0 errors; 3 pre-existingreact-hooks/exhaustive-depswarnings untouched by this PR)pnpm typecheck— 19/19 tasks passpnpm testWHEREconjunct on an existing query; deliberately no new control loop (rule 47).git diff --statshows zero changes underapps/api/src/scheduled/and noalarm(additions.Test totals, reconciled against the pre-change baseline (rule 02 — a green count is not a green suite):
apps/apipnpm testapps/apipnpm test:workers(real workerd + real DO SQLite + real migration chain)packages/sharedapps/webagent-context-audit.spec.tspnpm quality:do-migration-safetyI checked per-file collection status, not just assertion counts: the first API run (before building
providers/cloud-init) showed 115 files failing to import, and the file total stayed at 599 across both runs — that is how I confirmed nothing silently vanished from the suite.Discriminating proofs
Every new guard was verified by deleting it and confirming exactly the intended tests go red, then restoring:
APPLIES_NOW_SQL→ tautologyvalidatePolicyLifecyclecall in the SAM-session writerStaging Verification (REQUIRED for all code changes — merge-blocking)
packages/cloud-init/,packages/vm-agent/, DNS, TLS, orscripts/deploy/. (scripts/quality/is a CI check, not provisioning infrastructure.)Staging Verification Evidence
Deploy run 32673192429 — success.
GET https://api.sammy.party/health→{"status":"healthy"}.Verified against live staging (
app.sammy.party/api.sammy.party), authenticated viaPOST /api/auth/token-loginwithSAM_PLAYWRIGHT_PRIMARY_USER→ 200. Project01KTKXZ4ZZAT6MJFXRW1ZTQ7RB(hono).1. Write boundary — every rejection is live, with the real messages:
scope:'task', noexpiresAt400— "a task-scoped policy must set expiresAt so it cannot outlive the work it was captured for"expiresAtin the past400— "expiresAt must be in the future — use remove_policy to deactivate a policy immediately"expiresAtbeyond the horizon400— "expiresAt must be within 31536000000ms of now" (365 days — the configurable limit is live)scope:'forever'400— "scope must be one of: always, task"scope:'task'+ validexpiresAt201expiresAt2012. Merged-post-write-state validation is live (not just patch-level):
PATCH {expiresAt: null}on a task-scoped policy400— correctly refuses to resurrect a permanent one-shot policyPATCH {scope:'task', expiresAt}200PATCH {scope:'task'}on a standing policy with no expiry4003. The read filter — an expired policy is genuinely not injected into a real agent session.
This is the acceptance criterion that matters, so I exercised the real path rather than inferring it. I created a task-scoped policy expiring in 90s plus a standing control, let the expiry lapse, then started a real chat session on staging (
cf-container, "Claude Code Chat" profile) and asked the agent to callget_instructionsand report which of the two it could see:The control is what makes the absence meaningful. My first attempt failed it — I had told the agent "do not call any tool", so it never fetched
get_instructionsand answerednoto both. Without the control that would have read as a pass. Corrected prompt, re-ran, got the result above.4. Retention — an expired policy stays inspectable:
GET /policies/:id→200withscope:'task',active:true,lapsed:true; still present inlist_policies.5. UI, both viewports, live staging (
.tmp/staging/03-policies-{desktop,mobile}.png): the expired card rendersconstraint+active+task-scoped+expiredbadges and an "Expired Aug 23, 2026" footer; the standing control card renders neither lifecycle badge nor an expiry footer. No horizontal overflow at 375×667 or 1280×800. Zero console errors.A first-run setup wizard intermittently covered the page during this run. It silently swallowed every locator, and the "no badge on the standing card" assertions passed against a page the user never sees — the rule-62 trap. Caught by the liveness assertion, then fixed by dismissing the wizard and retrying; I also tightened liveness from "the word Policies appears" (which matched hidden nav text) to "policy cards actually rendered".
6. Regression sweep: dashboard loads and renders; project list returns 15 projects; project navigation, Agent Context tabs, and the policy filter all work; zero console errors across every page visited.
7. Cleanup: both probe policies deactivated, both test workspaces deleted (
{"success":true}), and staging D1 confirmsnodesare 193/193deleted— zero VMs at rest.UI Compliance Checklist (Required for UI changes)
expiredbadge, "Expired <date>" footer), not by colour alone; colour is supplementary emphasisBadgeand the same semantic tokens (bg-info-tint/text-info-fg,bg-warning-tint/text-warning-fg) already used by the category badges. No new dependency, no bundle impact;AgentContextPageis alreadyReact.lazy-loaded.The audit covers the standing / live-task-scoped / expired / worst-case matrix at 375×667, 390×844, 768×1024 and 1280×800, using
assertNoOverflowfromaudit-helpers.ts(which includes the clipped-overflow walk — rule 56), not a hand-rolleddocumentElement.scrollWidthcheck.Review caught that the worst case was split across two cards:
pl4carried the 200-character unbroken title but was not expired, so "longest title" and "most badges" never met on one card.pl4is now the genuine worst case — 200-char title carrying every badge (category + active + task-scoped + expired) — and both viewport tests assert the badges on that card rather than page-wide (page-wide would now be a strict-mode violation, not a pass).Screenshots were opened and checked, not merely produced (rule 62): the four viewport sizes produce visibly different files (286KB / 375KB / 691KB / 723KB), and the 375px capture confirms the standing-policy control renders with no lifecycle badge while the task-scoped card shows the badge and its title wraps cleanly.
End-to-End Verification (Required for multi-component changes)
Data Flow Trace
The R1 merge point is step 6. R1 moved the policy id inline into the rendered line; this PR inserts the lifecycle annotation before it. The pre-existing "renders ids in FULL" test lives in an untouched sibling file (
mcp-instruction-payload-dedup.test.ts) and was re-run: 14/14 pass. WhenformatPolicyLifecyclereturns''— which it does for every policy without an expiry, i.e. all 81 today — the line collapses back to byte-identical pre-change output.Untested Gaps
The DO's fresh-read guard is not exercised by two genuinely concurrent
updatePolicyRPCs. This is not the rule-45 class of bug: the DO's read → guard → write critical section contains noawait, so it completes within one JS turn and two RPCs to the same DO instance cannot interleave across it. An explicitPromise.alltest would be documentation rather than a correctness guard.Part 2 (production policy deactivation, acceptance criterion 8) is by design not actionable until this deploys. Results reported in a follow-up comment.
Post-Mortem (Required for bug fix PRs)
This is a feature PR, but it carries one genuine bug fix — the migration-safety gate — so the post-mortem covers that.
What broke
pnpm quality:do-migration-safetyreportedmigrations.ts: PASSwhile never inspecting one of the statements in the file. AnyDROP TABLE,DELETEwithoutWHERE, orUPDATEwithoutWHEREwritten in a double-quoted string would have shipped through a green CI gate. On a Durable Object that is unrecoverable — there is no D1-style time travel.Root cause
extractSqlFromTypeScript(scripts/quality/check-do-migration-safety.ts) matched only two of JavaScript's three string forms: backtick and single-quoted. Double-quoted strings were never extracted, so they were never scanned. Double quotes are not an exotic choice — they are the natural choice for a statement that embeds a single-quoted SQL literal, which is why migration 034 (… DEFAULT 'always') was the first migration to trip it.Class of bug
A checker whose "pass" is the absence of input rather than the absence of danger — the same shape as rule 02's "a green test count is not a green suite" and rule 53's "silence is not success". A linter run on a path that matched no files, a coverage gate over an empty file set, and a SQL scanner that skipped a quoting style all report identically to a genuine pass. It is also a validated-input bug: the extractor silently defined what "all the SQL in this file" means, and nothing checked that definition against reality.
Why it wasn't caught
The gate's own test suite tested the checker's verdict on the real codebase (
expect(result).toContain('PASS')) and re-implemented its own regex scans over the migration files — but never tested the extractor against the string forms it claims to cover. Every test agreed with the extractor's blind spot because every test shared it. No test had ever fed the checker a known-dangerous statement and demanded it be caught.Process fix included in this PR
scripts/quality/check-do-migration-safety.ts— extractor now covers all three string forms, each pattern anchored to its own delimiter, with a comment explaining that a quoting style must never be able to bypass a gate rule 31 calls unbypassable. Exported for direct testing.scripts/quality/check-do-migration-safety.test.ts— six tests that feed the extractor each string form directly, including the exact shape of migration 034 and an end-to-end "aDROP TABLEhidden in a double-quoted statement is still caught". Verified discriminating.apps/api/src/durable-objects/migrations.ts— the migration-034 comment warning authors about quoted SQL in prose updated; it described the old backtick-only behaviour.The generalisable lesson — test the checker by giving it something it must catch, not only by checking that it likes the current codebase — is already stated in
.claude/rules/02-quality-gates.md("A Green Test Count Is Not A Green Suite", which explicitly extends to "schema checks that skip when the input is unparseable"). This is that rule's first concrete instance in the migration-safety tooling, so the fix is the test, not a new rule.Post-mortem file
This section, plus
tasks/active/2026-08-23-policy-lifecycle-controls.md.Specialist Review Evidence (Required for agent-authored PRs)
needs-human-reviewlabel added and merge deferred to human — N/A, all ten completedAll findings addressed in
c06642e8aunless noted.err.message→400 catch) not changed: matches the existing convention indeployment-environments.ts/gcp.ts/chat-comment-directives.tsand is not introduced here. Independently confirmed migration 034 is correctly sequenced after033and thatADD COLUMN … NOT NULL DEFAULTis proven against real workerd.Number.isFinite/isInteger/future/maxExpiryMs— nonew Date()overflow hazard), and logging all PASS. 1 MEDIUM unbounded row growth → tracked intasks/backlog/2026-08-23-policy-row-retention-bound.mdwith the reason a naive ceiling was rejected. 2 LOW noted, both pre-existing patterns.update_policylacked the REST route's I/O-budget regression — added, proven discriminating. The "move validation into the DO to drop 2 RPCs" suggestion was assessed and not taken: the race it closes is not exploitable (the DO guard already catches the only harmful merge), so it is an optimization, not a correctness fix.PolicyCardreadsDate.now()at render with no polling, so a card left open across the expiry moment shows stale state until the next render — accepted for a surface the page itself labels "Instruction-only until platform enforcement exists".architecture/overview.mddocumentsformatPolicyDirectives()output — now updated with the annotation format and read-time-filter semantics.CLAUDE.mdRecent Changes entry added. Confirmed the SAM-CLI OpenAPI contract has no/policiesroute, so no regeneration is needed.migrations.tsandpolicies.tstouch the table by name — no raw-SQL bypass) and confirmed all funnel through the DO choke point. Confirmedscopeis consumed, not write-only (rule 57). 1 MEDIUM (duplicated type-shape parsing) judged structurally justified — the three boundaries have incompatible error-return conventions and this mirrors the pre-existing pattern forcategory/title/confidencein the same files.DEFAULT_POLICY_MAX_EXPIRY_MS+POLICY_MAX_EXPIRY_MSfollow the established pattern and the override reaches all three write boundaries (verified behaviourally).'always'and the date formatting correctly categorised as enum/display, not config.POLICY_MAX_EXPIRY_MSwired identically to its fivePOLICY_*siblings; single resolver, no ad-hoc reads; correctly absent from deploy plumbing, matching existing convention. GH_/GITHUB_ N/A.Exceptions (If any)
project_policiesrow growth is not bounded by this PR.removePolicyis a soft delete. Bounding growth needs a retention/hard-delete design first.tasks/backlog/2026-08-23-policy-row-retention-bound.md.Agent Preflight (Required)
Classification
External References
N/A: no external API involved.This is entirely internal — SQLite DDL against Cloudflare Durable Object storage plus SAM's own MCP/REST surfaces. SQLite'sALTER TABLE ADD COLUMNconstraints were verified against ~10 prior migrations in the same file that already use theNOT NULL DEFAULT '<literal>'idiom successfully against real workerd, and confirmed by the real-DO test run rather than assumed.Codebase Impact Analysis
packages/shared—types/policy.ts(POLICY_SCOPES,PolicyScope,isPolicyScope, new fields on 3 interfaces),constants/policies.ts(DEFAULT_POLICY_MAX_EXPIRY_MS,maxExpiryMs, the sharedvalidatePolicyLifecycle)apps/api— DO migration 034;project-data/policies.ts,project-data/index.ts,row-schemas/policies.ts;services/project-data-policies.ts; MCPpolicy-tools.ts+tool-definitions-policy-tools.ts+instruction-tools.ts; RESTroutes/policies.ts+schemas/policies.ts;sam-session/tools/add-policy.ts;env.tsapps/web—AgentContextPage/PoliciesTab.tsx(display-only badges + footer)apps/www—architecture/overview.mdscripts/quality—check-do-migration-safety.ts(+ tests)Documentation & Specs
apps/www/src/content/docs/docs/architecture/overview.md— documented the lifecycle annotation in theget_instructionsbootstrap payload and the read-time filter semanticsCLAUDE.md— Recent Changes entrytasks/active/2026-08-23-policy-lifecycle-controls.md— research, rule-63 query enumeration, three-writer enumeration, checklist, acceptance criteriatasks/backlog/2026-08-23-policy-row-retention-bound.md— new, tracks the deferred growth boundConstitution & Risk Check
Principle XI (No Hardcoded Values):
DEFAULT_POLICY_MAX_EXPIRY_MS(365 days) with aPOLICY_MAX_EXPIRY_MSoverride, threaded throughresolvePolicyLimitsand reaching all three write boundaries. Registered inenv.tsalongside the five existingPOLICY_*vars.Principle XIII (Fail Fast): every write boundary fails closed on a
taskscope with no expiry, and the DO re-checks against freshly-read state as the final guard.Risks and tradeoffs:
expiresAtto the future and withinmaxExpiryMs, by retaining the row so a human can see it lapsed, and by surfacingexpiredin the UI. Accepted: this is the feature working as intended.instruction-tools.ts, resolved; the untouched full-uuid test re-run as the check.